Skip to content

feat(iptv): support multiple configurable Stalker portals with per-portal groups - #607

Open
ReichiMD wants to merge 16 commits into
ProdigyV21:mainfrom
ReichiMD:feat/stalker-live-config
Open

feat(iptv): support multiple configurable Stalker portals with per-portal groups#607
ReichiMD wants to merge 16 commits into
ProdigyV21:mainfrom
ReichiMD:feat/stalker-live-config

Conversation

@ReichiMD

Copy link
Copy Markdown

Summary

Adds first-class support for configuring multiple Stalker/Ministra portals (up to 3) alongside existing M3U playlists, so each portal's category groups can be managed independently in the Settings UI — hide/show, reorder, bulk toggle — exactly like M3U playlist groups. Previously Stalker was limited to a single portal exposed only through a config dialog, with no group management.

What changed

  • Multi-portal Stalker section (TV + mobile): Stalker portals get their own settings section, separate from M3U playlists. Each portal is rendered as a row with a full action-chip set (categories / enable-disable / edit / rename / move up / move down / delete), mirroring the M3U row layout. An "Add Stalker portal" button is capped at 3 portals and disables when full.
  • Independent groups per portal: Stalker channels use the stalker:<portalId>:<origId> id shape. Each portal's hidden groups and group order are stored under the portal's own id (e.g. stalker1|group) via the existing PlaylistGroupKey infrastructure, so managing one portal never affects another.
  • Rename dialog: Portal name is editable (defaults to "Portal N"); subtitle is the portal URL.
  • Categories dialog per portal: Reuses the M3U group screen, with a per-portal "Show all / Hide all" bulk toggle. Groups are only hidden/shown, never deleted — consistent with M3U behavior.
  • Enable/disable, sort, delete: Toggling a portal drops its channels from the grid; up/down reorders the section; deleting a portal also clears its persisted group preferences (hidden groups + order) so re-adding starts clean.

Bug fixes (pre-existing / discovered during development)

  1. TV grid ignored hidden groups and group order (all sources). hiddenGroups/groupOrder are stored as playlistId|groupName keys, but TvViewModel.buildPreparedGroups and currentVisiblePlaylistGroups compared them against plain group names — so hiding/ordering had no effect on the TV view. Both now extract the group name from each key before comparing.
  2. Touch UI hid Stalker groups by the literal stalker prefix instead of the portal id. The touch channel list derived the playlist id for hidden-group keys via channel.id.substringBefore(':'), which yields the literal stalker prefix for stalker:<portalId>:<origId> ids and therefore never matched the stored stalker1|group keys — hidden Stalker groups stayed visible on phones. A new helper resolves the portal id for Stalker ids (reusing StalkerPortalSupport.portalIdFromChannelId) and falls back to substringBefore(':') for M3U/Xtream, applied in buildCategoryIndex/buildCategoryTree/buildPagedStartupChannelState and the search filter. Category-id / sidebar keys are left unchanged.

D-pad navigation

All hardcoded index dispatch maps in SettingsScreen were updated for the new Stalker rows and the categories bulk-toggle row. Helpers:

  • iptvRowMaxAction() = 5
  • firstIptvGroupIndex(playlistId, groups, stalkerPortalIds)
  • sectionMaxIndex("iptv") = 4 + playlists.size + stalkerPortals.size

On-device TV testing confirmed correct focus, OK dispatch and left/right guards across all rows.

Files changed

File Change
StalkerPortalSupport.kt (new) STALKER_PLAYLIST_ID, MAX_STALKER_PORTALS, normalizeStalkerPortalEntry, portalIdFromChannelId, encode/decode
IptvRepository.kt saveStalkerPortals (normalize, cap at 3, clear prefs of removed portals); legacy saveStalkerConfig/clearStalkerConfig retained
SettingsViewModel.kt Stalker CRUD: add/edit/toggle/move up/down/remove/rename/manage categories; persistStalkerPortals() private
SettingsScreen.kt Stalker section (TV + mobile), rename dialog, per-portal categories + bulk toggle, D-pad index helpers & dispatch
TvViewModel.kt Fix PlaylistGroupKey mismatch in hidden-group/order filtering
LiveCategory.kt / LiveTvScreen.kt Fix hidden-group key to use portal id for Stalker on touch UI
strings.xml Section titles, "Portal N" template, content descriptions, bulk-toggle labels
Tests StalkerPortalSupportTest (normalize), StalkerDpadIndexTest (dpad indices)

Compatibility / scope notes

  • No portal URLs, MAC addresses, or tokens are included — only neutral placeholders/labels.
  • The cache-snapshot bugfix from the prior session (getMemoryCachedSnapshot/getCachedSnapshotOrNull) is intentionally untouched.
  • Fork-only CI helpers (test-apk.yml and .github/test-signing/) used during development have been removed from this branch; the upstream build-check.yml remains the gate.

This PR was created by an AI agent (OpenHands) on behalf of @ReichiMD.

Treat the Stalker/Ministra portal as a pseudo-playlist (id "stalker") so
its categories appear and can be managed in the Settings UI the same way
M3U playlist groups are:

- Stalker gets its own playlist row (TV + mobile) with action chips for
  categories / edit config / remove, instead of only a config dialog.
- The categories screen loads Stalker genres (already persisted via the
  `stalker:` channel id prefix) and supports per-group hide/show, move
  up/down, and reset order — reusing the existing M3U group infrastructure.
- A "Show all / Hide all" bulk-toggle button is shown for Stalker only,
  flipping every category in one operation.
- Groups are only hidden/shown, never deleted.

Also fixes a pre-existing bug where the TV grid ignored hidden groups and
group order: hiddenGroups and groupOrder are stored as `playlistId|groupName`
keys (PlaylistGroupKey), but TvViewModel compared them against plain group
names, so hiding/ordering had no effect. Both buildPreparedGroups and
currentVisiblePlaylistGroups now extract the group name from each key.

Removing a Stalker portal now also clears its persisted group preferences
(hidden groups + order) via clearGroupPreferences so re-adding a portal
starts clean.

Co-authored-by: openhands <openhands@all-hands.dev>
Wiederhergestellt aus den alten Branches (beim Fork-Aufräumen 22.08. verloren gegangen).
Baut eine DEBUG-APK via assembleSideloadDebug mit Application-ID-Suffix .stalker-live
(parallel installierbar neben produktiver ARVIO). Braucht keine Secrets.
Wird vor dem upstream-PR wieder entfernt.

Co-authored-by: openhands <openhands@all-hands.dev>
Co-authored-by: openhands <openhands@all-hands.dev>
Reworks Stalker from a single config object (stalkerPortalUrl /
stalkerMacAddress) into a real list of up to 3 StalkerPortalEntry items,
mirroring the M3U playlists model. This is the invisible backend foundation
for the multi-portal UI; the existing single Stalker settings row stays
functional via the first portal.

Data model & persistence:
- IptvConfig.stalkerPortals: List<StalkerPortalEntry>
- IptvCloudProfileState.stalkerPortals replaces the two legacy fields
- DataStore stalkerPortalsKey() stores JSON (analogous to playlistsKey())
- Legacy migration: old single-portal fields auto-become "Portal 1"
  (id stalker1) when the list store is empty

Playback & loading:
- cachedStalkerApi -> cachedStalkerApis Map<portalId, StalkerApi>
- resolveStalkerStreamUrl(channelId, command) routes by stalker:<portalId>:
  prefix; falls back to the first portal for legacy callers
- Channel ids prefixed stalker:<portalId>:<origId> so startsWith("stalker:")
  checks keep matching; all enabled portals load in parallel and merge

Signatures & sync:
- buildConfigSignature/buildSourceSignature and cloud/sync signatures
  derived from the portal list (version bumped to invalidate stale caches)
- Cloud sync exports/imports the portal list; legacy keys cleared on import

Compatibility: SettingsUiState exposes iptvStalkerUrl/iptvStalkerMac as
derived getters over portals[0] so the Session-1 settings UI is unchanged.

Unit tests cover migration, multi-portal prefix extraction, JSON round-trip,
max-portal cap, and blank-field filtering.

Co-authored-by: openhands <openhands@all-hands.dev>
Kotlin could not infer the type parameters for the empty fallback pairs
and success-branch Triples in the parallel Stalker-portal merge paths.
Make the Triple<String, StalkerApi?, List<IptvChannel>> and
emptyMap/emptyList fallbacks explicit so compileSideloadDebugKotlin succeeds.

Co-authored-by: openhands <openhands@all-hands.dev>
…date

When the versionCode stayed at 310 Android refused to install a new test
APK over the existing .stalkerlive build (same version), forcing a full
reinstall that would wipe the saved Stalker portal and defeat the
migration test. Each test build now derives a unique, monotonically
increasing versionCode (310 + github.run_number) so updates install
in place. Workflow-only change, removed before the upstream PR.

Co-authored-by: openhands <openhands@all-hands.dev>
Debug APKs built in CI each get a different ephemeral signing key, so
Android refuses in-place updates (signature mismatch). This generates a
fixed debug keystore once, to be stored as a repository secret and reused
by test-apk builds.

Co-authored-by: openhands <openhands@all-hands.dev>
Debug APKs built in ephemeral CI runners each get a different signing
key, so Android refuses to update one test build over another (signature
mismatch), regardless of versionCode. Store a fixed debug keystore in the
branch and copy it to ~/.android/debug.keystore before building so every
test APK is signed with the same key and can update in place.

The keystore uses the standard debug password and is removed (along with
test-apk.yml) before the upstream PR.

Co-authored-by: openhands <openhands@all-hands.dev>
getMemoryCachedSnapshot() and getCachedSnapshotOrNull() built the
IptvSnapshot without hiddenGroups/groupOrder, leaving them at the
emptyList() default. The TV grid reads these cached snapshots at
startup (and onChannelsReady reuses the live snapshot's hiddenGroups),
so the hidden-group filter had nothing to apply and every group became
visible again. Read both prefs from the DataStore the same way
loadSnapshot already does.
… categories

Session 2 — full Stalker portal UI (TV + Mobile):

SettingsScreen:
- Two separate sections (M3U Playlists + Stalker Portals)
- Up to 3 Stalker portal rows with full chip row
  (categories / enable / edit / rename / up / down / delete)
- 'Add Stalker portal' button disabled at max (3)
- Rename dialog (name editable, subtitle 'Stalker', no EPG counter)
- Per-portal categories dialog with per-portal bulk-toggle
- Enable/disable as icon (same style as M3U, no slider)
- Sort up/down within Stalker section only

D-Pad navigation:
- Recomputed index dispatch maps for variable M3U + Stalker row counts
- iptvRowMaxAction() replaces stalkerRowMaxAction() (both rows = 5 chips)
- firstIptvGroupIndex() accepts stalker portal ids for per-portal bulk-toggle
- sectionMaxIndex includes Stalker portal count

SettingsViewModel:
- Removed compat getters (iptvStalkerUrl/iptvStalkerMac)
- Real CRUD: onAddStalkerPortal, onEditStalkerPortal, onToggleStalkerPortal,
  onMoveStalkerPortalUp/Down, onRemoveStalkerPortal, onRenameStalkerPortal
- loadIptvGroupsForPlaylist is stalker-portal-aware (stalker:<portalId>: prefix)

IptvRepository:
- saveStalkerPortals() added (normalize, cap at 3, clear removed portal groups)
- STALKER_PLAYLIST_ID / MAX_STALKER_PORTALS in public companion object

strings.xml: new section title, portal-N template, content descriptions,
bulk-toggle labels, rename dialog strings

Tests: normalizeStalkerPortalEntry + D-Pad index helper unit tests

Co-authored-by: openhands <openhands@all-hands.dev>
…constants

Two companion objects in one class is illegal in Kotlin — the compiler
silently dropped the private one, making MULTI_SPACE_REGEX/HTTP_URL_REGEX
unresolved. Move the public constants to top-level in StalkerPortalSupport.kt
and keep the private companion object intact.

Co-authored-by: openhands <openhands@all-hands.dev>
MobileSettingsSubPage has its own local Stalker state vars; the input/rename
dialogs must live in that function, not in MobileCloudAccountSubPage where
the vars are unresolved.

Co-authored-by: openhands <openhands@all-hands.dev>
The mobile Stalker portal row previously rendered the full TV chip
row (categories, toggle, edit, rename, up, down, delete = 7 controls),
causing overflow/clipping on touch devices. Mirror the M3U mobile
row instead: show only the category selector and enable/disable
toggle, with tap-to-edit and long-press selection mode for reorder
(drag handle) and delete. TV rows and D-pad index maps are unchanged.
Hidden groups for Stalker portals are stored under the portal id
(e.g. `stalker1|group`), but the touch channel list derived the
playlist id from `channel.id.substringBefore(':')`, which yields the
literal `stalker` prefix for `stalker:<portalId>:<origId>` ids. The
resulting `stalker|group` key never matched the stored `stalker1|group`
key, so hidden Stalker groups stayed visible on phones.

Resolve the playlist id for hidden-group keys via the Stalker portal
segment (reusing StalkerPortalSupport.portalIdFromChannelId) and fall
back to substringBefore(':') for M3U/Xtream ids. Apply this in
buildCategoryIndex/buildCategoryTree/buildPagedStartupChannelState and
the search filter, leaving category-id/sidebar keys unchanged.

Also match the mobile Stalker row to M3U: LiveTv leading icon and the
portal URL as subtitle.
Align the TV Stalker portal row with the M3U row: a leading LiveTv
icon and the portal URL as subtitle (instead of the literal "Stalker"
label). Apply the same leading icon to the TV M3U row for visual
parity. Icons are decorative, so the D-Pad index dispatch maps and
focus slots are unchanged.

Drop the now-unused settings_stalker_subtitle string resource.
Drop the fork-only test-apk.yml workflow and the committed debug
keystore under .github/test-signing/, plus the now-obsolete gitignore
exception that whitelisted it. These were development-only helpers
for building sideload debug APKs and must not ship in the upstream PR.
@github-actions github-actions Bot added the area: android Changes to the Android app or Gradle build label Aug 24, 2026
@ProdigyV21

Copy link
Copy Markdown
Owner

This is a valuable feature and the overall direction looks good. I verified the exact merge against current main: Play and Sideload compile, the Sideload unit tests pass, and GitHub checks are green. However, I found a few functional cases that should be fixed before merging:

  1. Playback cache must include the portal ID. TvViewModel currently caches resolved Stalker streams using only the raw command. Two portals can return the same command, causing one portal to reuse another portal's resolved stream.

  2. Portal IDs can be duplicated. IDs are generated from the current list size. If Portal 2 is deleted from three portals, adding another creates a second stalker3. Please generate the first unused ID or use a stable UUID.

  3. Live TV categories are not fully isolated per portal. Category IDs still use the shared stalker prefix, so matching group names from different portals are combined. Live-sidebar hide/reorder actions can also save stalker|Group while filtering expects stalker1|Group. The portal ID needs to be used consistently for category creation, matching and actions.

  4. Please complete legacy migration. Existing channel IDs change from stalker:<channel> to stalker:stalker1:<channel>, but favourites, recents, last channel, hidden groups and ordering are not converted. Older cloud snapshots also use the removed single-portal fields and need backward-compatible import support.

  5. Cloud restore currently drops Stalker group ordering. The valid source-ID set only contains M3U playlist IDs; include the normalized Stalker portal IDs as well.

Please add regression tests for two portals returning the same command, delete-middle-then-add, duplicate group names across portals, legacy upgrade, and cloud round-trip. Once these are covered, this should be a strong addition.

Sign up for free to join this conversation on GitHub. Already have an account? Sign in to comment

Labels

area: android Changes to the Android app or Gradle build

Projects

None yet

Development

Successfully merging this pull request may close these issues.

3 participants